React全家桶与前端单元测试艺术|洞见
TL;DR——什么是好的单元测试?
其实我是个标题党,单元测试根本没有“艺术”可言。
好的单元测试来自于好的代码,如果说有艺术,那也是代码的艺术。
注:以下“测试”一词,如非特指均为单元测试。
单元测试的好坏在于“单元”而不在“测试”。如果一个系统毫无单元可言,那就没法进行单元测试,几乎只能用Selenium做大量的E2E测试,其成本和稳定性可想而知。科学的单元划分可以让你摆脱mock,减少依赖,提高并行度,不依赖实现/易重构,提高测试对业务的覆盖率,以及易学易用,大幅减少测试代码。
最好的单元是返回简单数据结构的函数:函数是最基本的抽象,可大可小,不需要mock,只依靠传参。简单数据结构可以判等。
最好的测试工具是Assert.Equal这种的:只是判等。判等容易,判断发生了什么很难。你可以看到后面对于DOM和异步操作这些和副作用相关的例子都靠判等测试。把作用幂等于数据,拿到数据就一定发生作用,然后再测数据,是一个基本思路。
以上是你以前学习测试第一天就会的内容,所以不存在门槛。
为什么不谈TDD?
首先,TDD肯定是有价值的(价值大小不论)。反对TDD的原因一般比较明显,对于TDD是否带来正收益不确定(动机不足)。 某些项目质量要求很高,预算宽绰,TDD势在必行。某些项目比较紧急,或者并非关键或无长期维护计划,TDD理由就不充分。
为什么谈测试?
因为测试难。
第一难学,第二难写。写测试是个挺困难的活,要在测试里正确重演业务要费好大劲,只能靠反复练习。虽然这些测试在某些项目中是值得的,但是可能并不适合其他某些项目的基本情况。
测试难,就代表训练成本高,生产成本也高,收益就下降。要提高采用TDD的动机,与其说服别人,不如从简化测试开始。
(图片来自:http://t.cn/Rpw9WKg)
为什么谈前端测试?
一般项目都是后端测试覆盖率高,同时后端套路也比较固定。测RESTful API粒度足够大,可以很好地避开实现并且覆盖业务。同时RESTful API一般也正好对应Web框架的Action handler,在这里同时它粒度也足够小,刚好可以直接调用而不启动真的Web server,使得测试最大程度并行化。所以这样测试收益总是最高的,争议很小。
前端不说套路不固定,测不测都有待商榷。因为前端流派不统一,资源不规则,边界也不清晰,有渲染又有点业务,有导航有请求,很多团队不测试/测Model/测Component/测E2E,五花八门。 但得益于JavaScript本身,前端测试其实是可以非常高效的。
下面你可以看到各种极简极快的测试工具和测试方式,并且它们完全可以贯穿开发始终,而非仅给Hello World体量项目准备的,你可以在很大的全家桶项目中完全机械地套用这些方法。(机械也是极限的一部分,你不应该在使用工具过程中面临太多抉择,而应当专注于将业务翻译成测试)。
为什么谈React全家桶?
前端从每周刷新一个框架,稳定到了Angular, React, Vue3个主流框架并存的阶段。网络中争论这三个框架盖的楼已经可以绕太阳系了。根据盖的各种大楼看来,现在哪个更优秀还没个定论。不过具体到单元测试方面,得益于Virtual DOM本身和模块化设计(不然全家桶白叫了),React全家桶明显更优秀些。
测试工具
我们本篇中的测试有三个目标:学得快,写得快,跑得快。
(图片来自:http://t.cn/RpwCke3)
平台上Selenium, Phantom, Chrome, 包括Karma都比较重,最好的测试框架就是直接跑在node上的。本着极限编程的原则,我们将测试本身和测试环境尽可能简化,以达到加快测试速度,最终反馈到开发速度的目的。
我们使用AVA进行测试,它非常简洁,速度非常快,和mocha不同,它默认会启动多线程并发测试。因此我们的测试必须减少共享状态来提高并发能力,不然就会出现意想不到的错误。安装和运行:
yarn add ava
ava --watch
这样可以运行并watch测试。改变代码测试结果会立刻改变,你也可以看到友善的错误信息,以及expected和actual之间的diff。写下第一段测试:
import test from 'ava'
test(t => {
t.is(1 + 1, 2)
})
除了is方法以外,我们还会用到deepEqual和true方法。好,你现在已经完全会用AVA了。其他的功能我们完全不关心。
Redux测试 (Model测试)
Redux就是用一堆Reducer函数来reduce所有事件用来做全局Store的状态机(FSM)。用源码本身介绍它甚至比用上一小段文字介绍还快:
const createStore = reducer => {
let state, listeners = []
const dispatch = action => {
state = reducer(state, action)
listeners.forEach(listeners => listeners())
}
return {
getState() { return state },
subscribe(listener) {
listeners.push(listener)
return () => { listeners = listeners.filter(l => l !== listener)}
},
dispatch,
}
}
这是一个简化版的代码,去掉了抛错等等细节,但功能是完整的。把你自己写的reducer扔进去,然后可以发事件来使其更新,你还可以订阅它来拿状态。有点像Event Sourcing,以消息而非调用来处理逻辑,更新和订阅的逻辑不在一起(事件是写模型,各种view就是多个读模型)。
reducer几乎包括了我们所有前端业务的核心,测好它就测了大半。它们全都是(State, Action) => nextState形式的纯函数,无异步操作,用swtich case来模拟模式匹配来处理事件。比如用喜闻乐见的简陋版的栈停车场举例:
export const parkingLot = (state = [], action) => {
switch (action.type) {
case 'parkingLot/PARK':
return [action.car, ...state]
case 'parkingLot/PICK':
const [_, ...rest] = state
return rest
default: return state
}
}
Reducer是这么用的:
const store = createStore(parkingLot)
store.subscribe(() => renderMyView(store.getState()))
store.dispatch({ type: 'parkingLot/PARK' })
好,现在你又理解了Redux。那我们可以看看怎么测试上面的parkingLot reducer了:
test('parking lot', t => {
const initial = parkingLot(undefined, {})
t.deepEqual(initial, [], 'should be empty when init')
const parked = parkingLot(initial, { type: 'parkingLot/PARK', car: 'Tesla Model S' })
t.deepEqual(parked, ['Tesla Model S'], 'should park Model S in lot')
const picked = parkingLot(parked, { type: 'parkingLot/PICK' })
t.deepEqual(picked, [], 'should remove the car')
})
它就是你第一天学测试就会写的那种测试。这些测试不受任何上下文影响,是幂等的。试着把那几个const声明的state挪到任何地方,你都可以发现测试还是正确的,这和我们平常小心翼翼分离各个测试case,并用beforeEach和afterEach重置截然不同。
(图片来自:http://t.cn/RpwS3AK)
测试Reducer是非常机械的,你不需要问自己“我到底应该测哪些东西”,只需要机械地测试初始state和每个switch case就好了。(小秘密:redux-devtools写完实现,在浏览器里打开,反过来还可以自动生成各种框架的测试代码,粘贴回来就行了。推荐不写测试的项目尝试下,反正白送的测试……而且跟你写的没两样)
随着业务变得复杂,当state树变大时,我们可以将reducer结构继续往下抽,并继续传递事件,函数没有this,重构起来比普通OO要简单得多,就不赘述了。这时候测试还是完全一样的,这种树形结构保证了我们能最大限度地覆盖一个bounded context—也就是root reducer。
另外更好的方式是用t.is(断言引用相同)而非t.deepEqual。但是JavaScript对象本身是可变的,引入immutable.js可以让你只用t.is测试,不过immutable的API有点别扭,不展开了。
组件测试 (View测试)
React是一个View library,它干的活就是DOM domain里的两个事:渲染和捕获事件。我们在这里依然从简,只用stateless component这个子集,虽然在用到生命周期方法的时候需要用一下class,但绝大多数时候应该只用stateless component。
它以Virtual DOM的形式封装了恶心的浏览器基础设施,让我们以函数和数据结构来描述组件,所以和大部分框架不同,我们的测试依然可以在node上并行运行。如果用Karma + Chrome真正地渲染测试,你会发现共享一个浏览器实例的测试非常慢,几乎无法watch测试,因此我们的TDD cycle就会变得不那么流畅了。
最基本的就是state => UI这种纯函数组件:
const Greeter = ({ name }) => <p>Greetings {name}!</p>
使用的时候就像HTML一样传递attribute就可以了。
render(<Greeter name="React"/>, document.body)
最简单的测试还是判等,我们用一个叫jsx-test-helpers的库来帮我们渲染:
import { renderJSX, JSX } from 'jsx-test-helpers'
const Paragraph = ({ children }) => <p>{children}</p>
const Greeter = ({ name }) => <Paragraph>Greetings {name}!</Paragraph>
test('Greeter', t => {
t.is(renderJSX(<Greeter name="React"/>),
JSX(<Paragraph>Greetings React!</Paragraph>),
'should render greeting text with name')
})
这里我多加了一层叫做Paragraph的组件,它的作用仅仅是传递给p标签,children这个prop表示XML标签传进来的子元素。多加这层Paragraph是为了展示renderJSX只向下渲染了一层,而非最终需要渲染的p标签。这样我们在View上的测试粒度就会变得更小,成本更低,速度更快。
(图片来自:http://t.cn/RpwYskG)
View不像业务本身那么稳定,细粒度低成本的快速测试更划算些,这也是为什么我们的View都只是接受参数渲染,这样你只用测很少的case就能保证View可以正确渲染。假如你的FSM Model有M种可能性,View显示的逻辑有N种,如果将两个集成在一起测试可能就需要M×N种Path,如果分开测就有M+N种。View和Model的边界清晰时,你的Model测试不容易被更困难的View测试干扰,View测试也减少了混沌程度,需要测试的情形就减少了。
我们的组件不应该只有渲染,还有事件,比如我们封装个TextField组件:
const TextField = ({ label, onChange }) => <label>
{label}
<input type="text" onChange={onChange} />
</label>
当然我们还可以判等,只要onChange函数引用相同就好了。
test('TextField', t => {
const onChange = () => {}
const actual = renderJSX(<TextField label="Email" onChange={onChange} />)
const expected = JSX(<label>
Email
<input type="text" onChange={onChange}/>
</label>)
t.is(actual, expected)
})
当然有时候你的组件更复杂些,测试时并不关心组件是不是完全按你想要的样子渲染,可能你想像jQuery一样选择什么,触发什么。这样可以用更主流的enzyme来测试:
import {shallow} from 'enzyme'
import sinon from 'sinon'
test('TextField with enzyme', t => {
const onChange = sinon.spy()
const wrapper = shallow(<TextField label="Email" onChange={onChange} />)
t.true(wrapper.contains(<label>Email</label>), 'should render label')
const event = { target: { value: 'foo@bar.com' } }
wrapper.find('input').simulate('change', event)
t.true(onChange.calledWith(event))
})
这里用的shallow顾名思义,也是向下渲染一层。此外我们还用了spy,这样测试就变得有点复杂了,丢掉了我们之前声明式的优雅,所以组件还是小一点、一下测完比较好。
还不够快?Facebook就觉得不够快,他们觉得View测试成本比较浪费,干脆搞了个Snapshot测试——意思就是照个像,只断言它不变。下次谁改了别的地方不小心影响到这里,就会挂掉,如果无意的就修好,如果有意的话和git一样commit一下就修好了:
import render from 'react-test-renderer'
test('Greeter', t => {
const tree = render.create(<Greeter name="React"/>).toJSON()
t.snapshot(tree, 'should not change')
})
当你修改Greeter的时候,测试就会挂掉,这时候运行:
ava --update-snapshots
就好了。Facebook自家的Jest对snapshot的支持更好,当snapshot不匹配时按个y/n就完事了,够快了吧。要有更快的可能就是不测了……
小结
这节里我们展示了3种测试View的不同方式,它们都比传统框架更简单更快速。我们的思路还是以判等为主,但不同于Model,粒度越大越好。View测试粒度越小越好,足够小、足够幂等之后,其实不用测试你也可以发现组件总是按照预期工作。相比之下MVVM天然有一种让View和Model粒度拟合的倾向,很容易让测试变得既难测又缺乏价值。
异步Effect测试
这算个续集……异步操作不复杂的项目可以无视这段,可以选择性不测。
React先解决了恶心的DOM问题,把Model的问题留下了。然后Redux把同步逻辑解决了,其实前端还留下异步操作的大问题没有解决。这种类似Unix“只做一件事”的哲学是React全家桶的根基。我们用一个叫做Redux-saga的库来展现全家桶的异步测试怎么写,Redux模仿的目标是Elm architecture,但是简化掉了Elm的作用模型,只保留了同步模型,Redux-saga其实就是把Elm的作用模型又拿回来了。
Saga是一种worker模式,很早之前在Java社区就存在了。Redux-saga抽象出来多种通用的作用比如call / takeEvery等等,然后有了这些作用,我们又可以愉快地判等了。比如:
import { takeEvery, put, call, fork, cancel } from 'redux-saga/effects'
function *account() {
yield call(takeEvery, 'login/REQUESTED', login)
}
function *login({ name, password }) {
try {
const { token } = yield call(fetch, '/login', { method: 'POST', body: { name, password } })
yield put({ type: 'login/SUCCEEDED', token })
}
catch (error) {
yield put ({ type: 'login/FAILED', error })
}
}
这段代码乍看起来很丑,这是因为它把程序里所有异步操作全都集中在自己身上了。其他部分都可以开心地发同步事件了,此外有了Saga之后Redux终于有了“用事件触发事件”的机制了,只用redux,应用复杂到一定程度你一定会想这个问题的。
这是个最普通的API处理saga,一个account worker看到每个'login/REQUESTED'就会forward给login worker(takeEvery),让它继续管下面的事。然后login worker拿到消息就会去发请求(call),之后傻傻地等着回复,或者是出错。最后它会发出和结果相关的事件。用这个方式你可以轻松解决疯狂难度的异步问题。
test('account saga', t => {
const gen = account()
t.deepEqual(gen.next().value, call(takeEvery, 'login/REQUESTED', login))
})
test('login saga', t => {
const gen = login({ name: 'John', password: 'super-secret-123'})
const request = gen.next().value
t.deepEqual(request, call(fetch, '/login', { method: 'POST', body: { name: 'John', password: 'super-secret-123'} }))
const response = gen.next({ token: 'non-human-readable-token' }).value
t.deepEqual(response, put({ type: 'login/SUCCEEDED', token: 'non-human-readable-token' }))
const failure = gen.throw('You code just exploded!').value
t.deepEqual(failure, put({ type: 'login/FAILED', error: 'You code just exploded!'}))
})
你看我们的测试连异步操作都还可以无耻地判等。call就是以某些参数调用某个函数,put就是发事件。
可以试着把fetch覆盖成空函数,你可以发现实际上副作用根本没发生,“fetch到底是个啥”对测试一点影响都没有。你可能发现了,其实saga就是用数据结构表示作用,而不着急执行,在这里又走回幂等的老路了。这和React Virtual DOM的思路异曲同工。
结语
首先是文章开头提到的TL;DR的内容。函数是个好东西,测函数不等同“测1+1=2”这种没营养的单元,函数是可以包含很大上下文的。这种输入输出的模型既简单又有效。
我们消灭了mock,减少了依赖,并发了测试,加快了速度,降低了门槛,减少了测试路径等等。如果你的React项目原来在TDD的边缘摇摆不定,现在是时候入一发这种唯快不破了。
全家桶让Model/View/Async这三者之间的边界变得清晰,任由业务变更,它们之间的职责是不会互相替代的,这样你测它们的时候才更容易。后端之所以测试稳定是因为有API。所以想让前端好测也是一样的思路。
文中好多次提到“幂等”这个概念,幂等可以让你减少测试的case,写代码更有底气。抛开测试不谈,代码幂等的地方越多,程序越可控可预期。其实仔细思考一下我们的实际项目,大部分业务都是非常确定的,并没有什么随机因素。为什么最后还是会出现很多随机现象呢?
声明优于命令,描述发生什么、想要什么比亲自指导具体步骤好。
消息机制优于调用机制。Smalltalk > Simula。其实RESTful API一定程度上也是消息。简单的对象直接互相作用是完全没问题的,人作为复杂对象主要通过语言媒介来交流,听到内容思考其中的含义,而不是靠肢体接触,或者像连体婴儿那样共享器官。所以才有一句俗语叫“你的对象都想成长为Actor”。
从View的几种测试里我们也可以看到,测试并不是只有测或者不测这两种选择,我们老提测试金字塔,意思是测试可多可少,不同层级的测试保持正金字塔形状比较健康,像今天我们说的就可以大幅加宽你测试金字塔的底座。所以你的项目有可能测试过少,也可能测试过度,所以时间可以动态调整。
没用全家桶的项目可以把“大Model小View”的思想拿走,这样更容易于专注价值。尽量抽出Model层,不要把逻辑写在VM里,看那样似省事,行数在测试里都还回来了。
- 相关阅读 -
点击【阅读原文】可至洞见网站查看原文&绿色字体部分的相关链接。
本文版权属ThoughtWorks公司所有,如需转载请在后台留言联系。